Skip to content

Conversation

@thetaPC
Copy link
Contributor

@thetaPC thetaPC commented Nov 5, 2025

Issue number: internal


What is the current behavior?

The screen reader does not announce when an option is selected within the action sheet interface. This is because the action sheet uses standard buttons, which do not support a detectable selected state via native properties or ARIA attributes like aria-checked or aria-selected, creating an inconsistent user experience across different interface types.

What is the new behavior?

  • Updated the action sheet buttons to accept role="radio"
  • Added keyboard navigation to follow the pattern for radio group
  • Added test

Does this introduce a breaking change?

  • Yes
  • No

Other information

Basic

@github-actions github-actions bot added the package: core @ionic/core package label Nov 5, 2025
@vercel
Copy link

vercel bot commented Nov 5, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
ionic-framework Ready Ready Preview Comment Nov 26, 2025 5:44pm

@thetaPC thetaPC changed the title Fw 6818 fix(select): use aria description for selected option Nov 5, 2025
@thetaPC thetaPC changed the title fix(select): use aria description for selected option fix(select): use aria description for selected option within action sheet Nov 5, 2025
@thetaPC thetaPC marked this pull request as ready for review November 5, 2025 21:11
@thetaPC thetaPC requested a review from a team as a code owner November 5, 2025 21:11
@thetaPC thetaPC requested a review from gnbm November 5, 2025 21:11
Copy link
Member

@ShaneK ShaneK left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me! Extremely minor grammatical nits, feel free to ignore if you don't wanna run CI just for them 🤷‍♂️

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
@thetaPC thetaPC changed the title fix(select): use aria description for selected option within action sheet fix(select, action-sheet): use radio role for options Nov 13, 2025
Copy link
Member

@ShaneK ShaneK left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, just a couple of minor things

@Watch('buttons')
buttonsChanged() {
// Initialize activeRadioId when buttons change
if (this.hasRadioButtons) {
Copy link
Member

@ShaneK ShaneK Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My only concern here is this.hasRadioButtons is only set in connectedCallback, I believe this means if buttons are set dynamically this value may become stale and this process may not trigger when it's supposed to. That's sort of an edge case for many, but it may be worth re-computing this.hasRadioButtons here, if you feel like this could be a concern. Up to you!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Co-authored-by: Shane <shane@shanessite.net>
Copy link
Member

@brandyscarney brandyscarney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this looks good, just one concern on the role!


return {
role: isOptionSelected(selectValue, value, this.compareWith) ? 'selected' : '',
role: 'radio',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you decide to pass role here instead of providing it as an htmlAttribute?

I'm worried this might introduce a breaking change.

For example, if anyone is relying on the role being returned as selected when clicking it and handling it through the following event:

window.addEventListener('ionActionSheetDidDismiss', function (e) {
  console.log('didDismiss', e.detail);
});

this would no longer work as expected.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While it's technically possible, relying on the role from the action sheet closing event is a bit of a weird way to do things. The best practice is to just use the <ion-select>'s own ionChange event. That's why I decided to fix the role, especially because the dismissal event would only pass back 'selected' if the user happened to click on an option that was already chosen.

Example:

  1. No selection has occurred
  2. User clicks on "apple"
  3. e.detail.role=""
  4. User clicks on "apple" again
  5. e.detail.role="selected"

Comment on lines 367 to 440
onKeydown(ev: KeyboardEvent) {
// Only handle keyboard navigation if we have radio buttons
if (!this.hasRadioButtons || !this.presented) {
return;
}

const target = ev.target as HTMLElement;

// Ignore if the target element is not within the action sheet or not a radio button
if (
!this.el.contains(target) ||
!target.classList.contains('action-sheet-button') ||
target.getAttribute('role') !== 'radio'
) {
return;
}

// Get all radio button elements and filter out disabled ones
const radios = Array.from(this.el.querySelectorAll('.action-sheet-button[role="radio"]')).filter(
(el) => !(el as HTMLButtonElement).disabled
) as HTMLButtonElement[];

const currentIndex = radios.findIndex((radio) => radio.id === target.id);
if (currentIndex === -1) {
return;
}

let nextEl: HTMLButtonElement | undefined;

if (['ArrowDown', 'ArrowRight'].includes(ev.key)) {
ev.preventDefault();
ev.stopPropagation();

nextEl = currentIndex === radios.length - 1 ? radios[0] : radios[currentIndex + 1];
} else if (['ArrowUp', 'ArrowLeft'].includes(ev.key)) {
ev.preventDefault();
ev.stopPropagation();

nextEl = currentIndex === 0 ? radios[radios.length - 1] : radios[currentIndex - 1];
} else if (ev.key === ' ' || ev.key === 'Enter') {
ev.preventDefault();
ev.stopPropagation();

const allButtons = this.getButtons();
const radioButtons = this.getRadioButtons();
const buttonIndex = radioButtons.findIndex((b) => {
const buttonId = this.getButtonId(b, allButtons.indexOf(b));
return buttonId === target.id;
});

if (buttonIndex !== -1) {
this.selectRadioButton(radioButtons[buttonIndex]);
this.buttonClick(radioButtons[buttonIndex]);
}

return;
}

// Focus the next radio button
if (nextEl) {
const allButtons = this.getButtons();
const radioButtons = this.getRadioButtons();

const buttonIndex = radioButtons.findIndex((b) => {
const buttonId = this.getButtonId(b, allButtons.indexOf(b));
return buttonId === nextEl?.id;
});

if (buttonIndex !== -1) {
this.selectRadioButton(radioButtons[buttonIndex]);
nextEl.focus();
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
onKeydown(ev: KeyboardEvent) {
// Only handle keyboard navigation if we have radio buttons
if (!this.hasRadioButtons || !this.presented) {
return;
}
const target = ev.target as HTMLElement;
// Ignore if the target element is not within the action sheet or not a radio button
if (
!this.el.contains(target) ||
!target.classList.contains('action-sheet-button') ||
target.getAttribute('role') !== 'radio'
) {
return;
}
// Get all radio button elements and filter out disabled ones
const radios = Array.from(this.el.querySelectorAll('.action-sheet-button[role="radio"]')).filter(
(el) => !(el as HTMLButtonElement).disabled
) as HTMLButtonElement[];
const currentIndex = radios.findIndex((radio) => radio.id === target.id);
if (currentIndex === -1) {
return;
}
let nextEl: HTMLButtonElement | undefined;
if (['ArrowDown', 'ArrowRight'].includes(ev.key)) {
ev.preventDefault();
ev.stopPropagation();
nextEl = currentIndex === radios.length - 1 ? radios[0] : radios[currentIndex + 1];
} else if (['ArrowUp', 'ArrowLeft'].includes(ev.key)) {
ev.preventDefault();
ev.stopPropagation();
nextEl = currentIndex === 0 ? radios[radios.length - 1] : radios[currentIndex - 1];
} else if (ev.key === ' ' || ev.key === 'Enter') {
ev.preventDefault();
ev.stopPropagation();
const allButtons = this.getButtons();
const radioButtons = this.getRadioButtons();
const buttonIndex = radioButtons.findIndex((b) => {
const buttonId = this.getButtonId(b, allButtons.indexOf(b));
return buttonId === target.id;
});
if (buttonIndex !== -1) {
this.selectRadioButton(radioButtons[buttonIndex]);
this.buttonClick(radioButtons[buttonIndex]);
}
return;
}
// Focus the next radio button
if (nextEl) {
const allButtons = this.getButtons();
const radioButtons = this.getRadioButtons();
const buttonIndex = radioButtons.findIndex((b) => {
const buttonId = this.getButtonId(b, allButtons.indexOf(b));
return buttonId === nextEl?.id;
});
if (buttonIndex !== -1) {
this.selectRadioButton(radioButtons[buttonIndex]);
nextEl.focus();
}
}
}
onKeydown(ev: KeyboardEvent) {
// Only handle keyboard navigation if we have radio buttons
if (!this.hasRadioButtons || !this.presented) {
return;
}
const target = ev.target as HTMLElement;
// Ignore if the target element is not within the action sheet or not a radio button
if (
!this.el.contains(target) ||
!target.classList.contains('action-sheet-button') ||
target.getAttribute('role') !== 'radio'
) {
return;
}
// Get all radio button elements and filter out disabled ones
const radios = Array.from(this.el.querySelectorAll('.action-sheet-button[role="radio"]')).filter(
(el) => !(el as HTMLButtonElement).disabled
) as HTMLButtonElement[];
const currentIndex = radios.findIndex((radio) => radio.id === target.id);
if (currentIndex === -1) {
return;
}
const allButtons = this.getButtons();
const radioButtons = this.getRadioButtons();
// Build a map of button element IDs to their ActionSheetButton config objects.
// This allows us to quickly look up which button config corresponds to a DOM element
// when handling keyboard navigation (e.g., when user presses Space/Enter or arrow keys).
// The key is the ID that was set on the DOM element during render, and the value is
// the ActionSheetButton config that contains the handler and other properties.
const buttonIdMap = new Map<string, ActionSheetButton>();
radioButtons.forEach((b) => {
const allIndex = allButtons.indexOf(b);
const buttonId = this.getButtonId(b, allIndex);
buttonIdMap.set(buttonId, b);
});
let nextEl: HTMLButtonElement | undefined;
if (['ArrowDown', 'ArrowRight'].includes(ev.key)) {
ev.preventDefault();
ev.stopPropagation();
nextEl = currentIndex === radios.length - 1 ? radios[0] : radios[currentIndex + 1];
} else if (['ArrowUp', 'ArrowLeft'].includes(ev.key)) {
ev.preventDefault();
ev.stopPropagation();
nextEl = currentIndex === 0 ? radios[radios.length - 1] : radios[currentIndex - 1];
} else if (ev.key === ' ' || ev.key === 'Enter') {
ev.preventDefault();
ev.stopPropagation();
const button = buttonIdMap.get(target.id);
if (button) {
this.selectRadioButton(button);
this.buttonClick(button);
}
return;
}
// Focus the next radio button
if (nextEl) {
const button = buttonIdMap.get(nextEl.id);
if (button) {
this.selectRadioButton(button);
nextEl.focus();
}
}
}

Simplify to avoid querying the buttons multiple times. You may want to make this change yourself instead of accepting mine as I probably messed up spacing when I pasted.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package: core @ionic/core package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants